# 第十五章 前端轮播图管理

从本章节我们将着手开发lin-cms前端部分的内容,和后端部分内容的顺序一样,我们先从轮播图管理相关的页面开始入手,纵观整个专栏项目的前端部分内容,轮播图管理也是最复杂(相对来说)的一部分内容,因为里面涉及到很多前端页面的交互操作,这章节的内容啃下来后,后面的内容就基本没什么难度了,所以这一章节的内容将会很啰嗦,也会很基础,但不乏一些知识点,这和前面后端部分内容的特点也是很类似的,所以同样推荐您耐心阅读!事不宜迟,让我们开始写代码吧!

# 轮播图列表页面

这里我们第一个要实现的页面就是展示所有轮播图的列表页面,运行webStorm,通过IDE打开工程目录,按照lin-cms的开发规范,我们在根目录下的src\views下新增一个目录operation,接着在这个operation目录下面再新增一个目录banner,目录创建好之后右键这个banner目录——New——选择Vue Component(Vue 组件):

组件(opens new window) 在Vue中是一个很重要的概念。一个由Vue构建而成项目,就是由一个根组件+无数个子组件组成的,而且子组件又可以嵌套子子组件,是树状结构的。我们平时看到的由Vue构建而成的web应用,在上面点击切换不同页面,其实就是在访问根组件,只不过是切换(路由)了里面不同的子组件显示不同的内容,所以我们也称使用Vue构建的web项目为单页面应用。我们在对Vue项目进行编译打包后,只会有一个index.html,这也是单页面应用的特征,这有别于以往我们编写的web前端项目,过去的项目目录下都会存在各种xxxx.html对应不同的页面

选择后会弹出一个对话框让你输入Vue Component的名字,这里我们起个名字叫List然后回车,IDE就会帮我们创建一个后缀名为.vue的文件,并自动填充一些模板代码:

一个组件由三部分组成,template(写html代码)、script(写js代码)、style(写CSS样式)。传统的web前端应用,这三种类型的内容我们是分开文件编写的,但是在Vue里面,都是写在同一个.vue文件的不同标签位置里面。这种开发模式让每一个组件可以保持其独立性,因为组件内写的内容只会对当前组件有效(样式除外,但一般我们会对组件的样式加上作用域的限制,让其样式是对当前组件生效),组件既可以直接作为一个页面来用,也可以作为其他页面的一部分。

vue文件创建好了,但这里我们先不着急写我们的页面,我们先在这个List.vue文件里写点测试代码,让它能够在lin-cms里显示出来:

<template>
    <div>
        我是轮播图列表
    </div>
</template>

<script>
export default {
  name: 'List',
}
</script>

<style scoped>

</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

注意:按照开发规范,src\views目录下的.vue文件都是作为页面组件(主要用于展示其他子组件),我们统一简称为页面。

页面文件定义好之后,我们要把这个页面添加到路由表里面,这样这个页面才能被访问到,lin-cms的路由配置文件都存放在项目根目录下的src\config\stage目录中,这里我们需要新增一个配置文件operation.js,并加入如下代码:

const operationRouter = {
  route: null,
  name: null,
  title: '运营管理',
  type: 'folder', // 类型: folder, tab, view
  icon: 'iconfont icon-tushuguanli', // 菜单图标
  filePath: 'views/operation/', // 文件路径
  order: 2,
  inNav: true,
  children: [
    {
      title: '轮播图管理',
      type: 'folder',
      route: '/operation/banner',
      filePath: 'views/operation/banner/',
      inNav: true,
      icon: 'iconfont icon-tushuguanli',
      children: [
        {
          title: '轮播图列表',
          type: 'view',
          route: '/operation/banner/list',
          filePath: 'views/operation/banner/List.vue',
          inNav: true,
          icon: 'iconfont icon-tushuguanli',
        },
      ],
    },
  ],
}

export default operationRouter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

这里我们定义了个菜单运营管理,菜单下面包含一个子菜单轮播图管理,这个子菜单下面有一个叫轮播图列表的页面,从一些配置项可以看出,我们这个页面指向了我们刚刚创建的List.vue文件了,同时给这个页面定义了一个路由地址/operation/banner/list,当我们访问域名/operation/banner/list这个url的时候就会路由到这个.List.vue的页面内容。

是不是对这一坨配置项感觉有点蒙圈,不慌,虽然lin-cms路由配置的配置参数较多,但是官方开发文档有详细的解释,这里就不重复赘述了,读者可自行查阅官方的开发文档中关于路由配置(opens new window) 部分的内容。

路由配置文件定义好之后,我们需要让lin-cms-vue加载这个路由,在配置文件同级目录下有一个index.js文件,这里就是负责加载所有路由配置文件的,双击打开这个文件,在文件顶部声明引入我们刚刚定义好的路由配置文件:

import adminConfig from './admin'
import bookConfig from './book' // 引入图书管理路由文件
import operationConfig from './operation' // 引入运营管理路由文件
import pluginsConfig from './plugins'
import Utils from '@/lin/utils/util'


let homeRouter = [
...................................
...................................
...................................

1
2
3
4
5
6
7
8
9
10
11
12

引入了之后,我们就需要把这个配置文件加入到下面homeRouter的定义中去:

import adminConfig from './admin'
import bookConfig from './book' // 引入图书管理路由文件
import operationConfig from './operation' // 引入运营管理路由文件
import pluginsConfig from './plugins'
import Utils from '@/lin/utils/util'

let homeRouter = [
  {
    title: '林间有风',
    type: 'view',
    name: Symbol('about'),
    route: '/about',
    filePath: 'views/about/About.vue',
    inNav: true,
    icon: 'iconfont icon-iconset0103',
    order: 0,
  },
  {
    title: '日志管理',
    type: 'view',
    name: Symbol('log'),
    route: '/log',
    filePath: 'views/log/Log.vue',
    inNav: true,
    icon: 'iconfont icon-rizhiguanli',
    order: 1,
    right: ['查询所有日志'],
  },
  {
    title: '404',
    type: 'view',
    name: Symbol('404'),
    route: '/404',
    filePath: 'views/error-page/404.vue',
    inNav: false,
    icon: 'iconfont icon-rizhiguanli',
  },
  bookConfig,
  adminConfig,
  // 运营管理的路由配置文件
  operationConfig,
]

...................................
...................................
...................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

从上面的代码可以看出,其实我们直接把operationConfig.js里的内容直接写在这个homeRouter数组里面也是可以的,但是随着你页面越来越多,这里的路由配置信息就会很长,后面维护和管理都会是灾难,所以这里我们选择为每个页面模块建立一个配置文件,然后在index.js这里导入即可。

这个和Vue 的组件化开发思想是一致的。把原本一个很复杂的页面组件内容拆分成由多个组件组成,方便维护和扩展、复用。

如果一切顺利,这时候如果我们运行lin-cms-vue,就可以在左边菜单看到一个新的菜单项运营管理了,在IDE中召唤出命令行工具,输入npm run serve,在开发环境启动完成之后,访问lin-cms-vue的地址并登陆:

开发过程中请保持lin-cms-tp5和lin-cms-vue内置web服务器的开启和MySQL数据库能正常访问,后面章节的内容不会再重复提醒。

这里可以看到左边的菜单多出来了一个菜单项,同时这个菜单项的目录结构也与我们在operation.js中定义的内容是一样的,接着我们点击一下这个轮播图列表的菜单项,点击之后可以看到当前页面的url发生了变化:

http://localhost:8080/#/operation/banner/list

同时lin-cms打开了一个新的标签页,标签页中显示了我们在List.vue文件中写的测试内容。

这里说明我们的页面已经是被正确加载并显示了,搞清楚怎么让一个新建的页面跑起来这个过程很重要,特别是第一次接触一个陌生的框架的时候,后面的章节内容中我们就不会再重复这里的过程了。

这里读者可能会问,为什么作者知道要这么写,答案就是看lin-cms-vue本身的示例代码,复制粘贴加阅读文档,当然需要再加一点点点vue使用经验。

测试页面跑起来之后,我们就要来正儿八经的写我们的页面了,首先我们要先想想这页面要写成什么样子,一般页面都会有专门的设计师或者UI给你画好设计图,不过这里我们自然是没有的,我们后续的所有页面就都参考线上demo(opens new window) 的样式来编写,当然如果你有其他的想法也可以随意编写。

从线上demo的轮播图列表页面可以观察到,我们这个展示轮播图列表的页面主要分成三个区域,标题、按钮区、表单区域,确定好这一点就后,我们先把这个骨架搭建一下,回到我们banner\List.vue文件中,添加以下内容:

<template>
    <div>
        <div>轮播图列表</div>
        <div>
            <el-button>新增</el-button>
        </div>
        <div>
            <el-table></el-table>
        </div>
    </div>
</template>

<script>
</script>

<style scoped>
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

保存后,会看到切换窗口到浏览器页面,会发现页面刷新了,然后会看到我们刚刚已经编写好的元素:

由于还没有编写样式,所以比较丑,接下来我们处理下样式问题:

<template>
    <div class="lin-container">
        <div class="lin-title">轮播图列表</div>
        <div class="button-container">
            <!-- 指定button类型 -->
            <el-button type="primary">新增</el-button>
        </div>
        <div class="table-container">
            <el-table></el-table>
        </div>
    </div>
</template>

<script>
export default {
  name: 'List',
}
</script>

<style lang="scss" scoped>
    .button-container{
        margin-top: 30px;
        padding-left: 30px;
    }

    .table-container{
        margin-top: 30px;
        padding-left: 30px;
        padding-right: 30px;
    }
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

这里我们给几个div标签加了class属性并指定了样式名,其中lin-containerlin-title这两个样式是属于lin-cms-vue内置的公共样式,从样式类名可以看出就是用来给页面容器和标题添加样式的。

这里为什么我知道有这两个样式呢?答案就是通过查看lin-cms-vue的示例页面,觉得这个样式好看,然后去看对应页面的源码。

button-containertable-container是我们自定义的样式类名,主要就是调整一些边距和内边距,保存一下,回到浏览器中,刷新一下页面:

lin-cms-vue提供了很多示例代码和页面,位于左侧菜单的自定义组件UI这两个菜单下,推荐读者了解一下。一方面我们可以知道lin-cms-vue封装了什么好用的组件,另一方面可以参考一些布局样式。

现在看起来就舒服多了,但是我们可以发现下面这个表单里面并没有数据,接下来我们就要来实现从前端发起HTTP请求从后端接口获取数据,根据返回的数据来让这个表单显示数据。在src\models目录下,我们新增一个banner.js文件:

models,很熟悉的文件夹名,是的,和之前我们在开发后端部分的内容一样,我们也会给前端的工程做软件架构分层,目的也是一样的,就是为了实现解耦。我们可以选择直接在List.vue里发起一个HTTP请求,但是那样代码就太臃肿了,特别是当你页面需要发起很多个不同的请求的时候。我们划分出一个models层,这个层里面的js文件就对应不同功能模块的模型类,每个模型类负责处理具体业务逻辑。List.vue是一个页面文件,它只需要从模型层里拿数据即可,至于数据怎么来了它不关心。这么做有利于模型类的复用(当其他页面也需要请求同一个接口),同时能保持展示页面代码的简洁(比如现在我们在编写的List.vue),对后期维护或者扩展也很有帮助。

在模型层下的banner.js中,我们定义了一个模型类Banner,我们在模型类中新增一个方法getBanners():

import {
  get,
} from '@/lin/plugins/axios'

class Banner {
  async getBanners() {
    const res = await get('v1/banner')
    return res
  }
}

export default new Banner()

1
2
3
4
5
6
7
8
9
10
11
12
13

这里我们首先从@/lin/plugins/axios引入了一个get()函数,这个文件位于项目根目录下的src\lin\plugins\axios.js(这里的@等同于到src这一层级的路径地址),axios.js里的内容其实就是对著名HTTP请求库axios的封装,通过对其封装实现了全局统一的异常处理和附加请求参数等机制,以往这些内容都需要我们自己来封装,但使用lin-cms-vue的话不需要,已经封装好了。同时为了简化调用代码,提供了诸如get()post()put()_delete()函数对应不同的HTTP请求类型。这里我们利用了get()函数来实现发起一个GET请求,该函数接收一个参数,参数值就是接口地址,前面我们在第三章《LinCMS全家桶》章节的时候已经配置过了根目录下的.env.development文件:

ENV = 'development'

VUE_APP_BASE_URL = 'http://localhost:8000/'
1
2
3

我们在利用lin-cms-vue封装好的请求类库里的请求函数时,只需要传递接口地址中除去域名部分以外的内容,axios.js的内部实现会在发起请求时根据这个VUE_APP_BASE_URL环境变量里的值去拼接完整路径,这么做的好处很简单,如果有一天我们改变了接口地址,只需要修改这里即可。

.env.development是针对开发环境的配置文件,即运行npm run serve后启动了内置web服务器的情况下才会应用配置。目录下还有一个.env.production,从文件名可以看出,这个是针对生成环境的,即最终我们开发完毕了准备部署了,运行npm run build后才会应用的配置。

这里我们还使用了ES6标准(opens new window) 提供的新特性async/await(opens new window) 。在函数名前面加上async表示声明这个函数里面有异步操作,在函数内的某一行代码语句前加上await表示要等待这条语句执行完才继续执行后面的代码。

async/await的作用就是使得异步操作变得更加方便,它是为了解决异步编程开发体验问题的解决方案,让你像写同步代码一样写异步代码。具体的差异读者可自行百度关于JavaScript异步编程的资料,看看使用旧语法的情况下写异步代码时多么麻烦和臃肿就明白了。

模型方法定义好之后,让我们回到页面中调用一下,打开List.vue,添加以下内容:

<!-- src/views/operation/banner/List.vue -->
<template>
    ..................
    ..................
    ..................
</template>

<script>
// 可以简写成 
//import banner from '@/models/banner'
import banner from '../../../models/banner' 

export default {
  name: 'List',
  // 组件的数据对象
  data() {
    return {
      // 定义一个数据对象
      bannerList: [],
    }
  },
  // 生命周期钩子,在该组件被创建后触发,执行该函数内的逻辑
  created() {
    // 当组件被创建时,调用组件内的getBanners()方法
    this.getBanners()
  },
  // 组件的选项之一,用于定义该组件所拥有的方法
  methods: {
    // 获取所有轮播图数据
    async getBanners() {
      // 调用banner模型类下的方法,并将结果赋值给组件的数据对象实现数据绑定并响应渲染到页面上
      this.bannerList = await banner.getBanners()
    },
  },
}
</script>

<!-- lang用于声明使用什么预编译技术来书写css,这里指定为scss,具体好处读者自行百度 -->
<!-- 写上scoped属性,代表这个style标签里的内容只对当前组件生效 -->
<style lang="scss" scoped>
   ..................
   ..................
   ..................
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

在script标签内部,我们在data(){}中申明一个组件数据对象bannerList,默认值是一个空数组,只有在这个data()里声明的数据对象,才可以动态绑定到template标签内的页面元素里。接着我们在Vue组件的生命周期钩子created()里调用一个我们定义的组件方法getBanners(),组件方法内部的实现就是调用我们的banner模型类里的方法获取轮播图数据,然后赋值给我们的bannerList数据对象。

每一个Vue组件,从创建到销毁,会经历多个不同的阶段,这叫组件的生命周期(opens new window) ,就像人一样,从出生,幼年,青年,中年,老年,死亡一样,这是人的生命周期;Vue框架给组件生命周期里的这几个阶段定义了不同的“钩子函数”,组件到了什么阶段,就触发对应的钩子函数,方便开发者在不同阶段实现一些特别的行为,比如这里,我们在组件被创建的阶段(从生命周期上来说,这个组件还没被渲染到页面中)就调用一个方法发起请求并把结果绑定到组件的数据对象中,这样给用户使用的感觉就是一打开页面数据就加载好了。

获取数据的逻辑我们编写好了,接下来就是要把这个bannerList的内容绑定到页面上去然后渲染出来,前面我们已经把el-table标签放置在了页面中,但是我们并没有给这个组件传递数据,所以目前我们的页面虽然显示了一个表格,但是没有数据,接下来我们就要让这个标签能够接收并渲染bannerList里的内容:

<!-- src/views/operation/banner/List.vue -->
<template>
    <div class="lin-container">
        <div class="lin-title">轮播图列表</div>
        <div class="button-container">
            <el-button type="primary">新增</el-button>
        </div>
        <div class="table-container">
            <!-- <el-table v-bind:data="bannerList"> -->
            <!-- v-bind:data可以简写成: -->
            <el-table :data="bannerList">
                <!-- label定义列头显示的文本,prop定义要渲染data数组中元素的哪个字段,width定义列宽 -->
                <el-table-column label="id" prop="id" width="120"></el-table-column>
                <el-table-column label="轮播图名称" prop="name"></el-table-column>
                <el-table-column label="轮播图简介" prop="description"></el-table-column>
            </el-table>
        </div>
    </div>    
</template>

....................................
....................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

通过给el-table标签的data属性传递我们的数据对象bannerList,传递的方法就是通过数据绑定语法v-bind:属性名=值,告诉这个标签里面要渲染什么数据,接着在el-table标签内部嵌套一个el-table-column标签定义表格每一列要渲染的内容。

lin-cms-vue集成了知名的ui库element ui,是的,就是那个日夜陪伴你的饿了么APP团队开源的ui库。el-table也是其中一个组件,所以这里如果你要问我为什么知道这个组件是这么用的,答案就是看文档,请读者自行阅读和收藏Element UI 文档(opens new window) ,这对于你看懂本专栏的示例代码很重要,因为每个组件都有很多属性和用法,作者无法事无巨细的逐一解释。另外养成自己阅读文档的习惯也有利于你后面独立开发,因为你时不时就会需要去文档里看看有啥组件能满足你的需求。

定义完之后,让我们回到浏览器中,刷新一下页面:

看到没有,看到没有,我们的轮播图数据都展示在了页面上了!!不过这里还有些需要完善的地方,一般在列表页面,我需要给表格中的每一行提供一些操作按钮,比如说编辑、删除,要实现按钮的方式也很简单,就是给表格加一列:

<!-- src/views/operation/banner/List.vue -->
<template>
      ...............................
      ...............................
<!-- <el-table v-bind:data="bannerList"> -->
<!-- v-bind:data可以简写成: -->
     <el-table :data="bannerList">
                <!-- label定义列头显示的文本,prop定义要渲染data数组中元素的哪个字段,width定义列宽 -->
                <el-table-column label="id" prop="id" width="120"></el-table-column>
                <el-table-column label="轮播图名称" prop="name"></el-table-column>
                <el-table-column label="轮播图简介" prop="description"></el-table-column>
                <el-table-column label="操作" fixed="right" width="170">
                    <!-- <el-table-column>标签支持在标签内嵌套一个<template>标签实现复杂的页面元素 -->
                    <template slot-scope="scope">
                        <el-button plain size="mini" type="primary" @click="handleEdit(scope.row)">编辑</el-button>
                        <el-button plain size="mini" type="danger" @click="handleDel(scope.row.id)" v-auth="'删除轮播图'">删除</el-button>
                    </template>
                </el-table-column>
            </el-table>
      ...............................
      ...............................        
</template>

....................................
....................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

<template>标签的使用就是Vue里关于插槽(opens new window) 的内容,插槽是什么读者可以通过文档轻松理解,但是这里使用的是插槽的另一种高级用法——作用域插槽(slot-scope="scope")。要理解什么作用域插槽之前,我们先明确一个概念。首先,我们的List.vue本身也是属于一个组件,我们使用的el-table、el-button标签其实也是一个组件,el-table、el-button标签是引入了element ui库之后提供给我们使用的。概念知道了,接下来看看我们之前都做了什么,我们把List.vue中定义的数据对象bannerList传递给了el-table实现表格渲染,数据是传进去了,表格也出来了,但是问题也来了,我们使用表格一般都会伴随着对某一行或者列的操作,这些操作其实都是在操作el-table组件。前面我们说了,组件的特性就是独立性,本身内部的数据是互相隔离的,那么这里我们放置的el-button组件需要监听点击事情来确定操作了哪一行,这个答案只有el-table知道,但是因为组件内部数据是隔离的,el-button是不能直接访问el-table里的数据的,而解决办法就是作用域插槽。作用域插槽在这里的作用就是把当前行的数据暴露出来给外部,就是<template slot-scope="scope">"这一段,通过声明暴露一个scope属性,后面el-button就可以用scope.row来访问到当前行的数据,也就是bannerList数组里的某个元素。关于作用域插槽的解释,官方文档也是解释得比较简陋,这里权当补充,如果读者还是无法理解,不要紧,用多几次,你就明白了,我也是这么过来的。

这里我们新增了一个el-table-column标签,就是增加一列,然后在标签里面嵌套了一个template标签,标签里面又放置了两个el-button标签,就是按钮,这里我们给这两个按钮添加了点击事件的监听,利用vue提供的@click=回调方法这种语法。当点击按钮的时候就会触发相应的回调方法,回调方法里面实现具体处理逻辑。方法需要我们在script标签里的methods选项中定义,定义和具体的实现我们先不着急写,这里先介绍一下删除按钮上面的v-auth,这个是lin-cms-vue封装的自定义指令(opens new window) ,通过这个指令,我们可以实现具体到页面中某个元素的权限控制,还记得之前我们在编写后端删除轮播图接口时,我们给接口增加了权限控制:

这里我们给删除轮播图这个接口定义的权限名叫删除轮播图,我们把这个权限名写到v-auth指令中,这样如果当前登录cms的账户没有拥有一个名叫“删除轮播图”的权限,那么这个按钮就会被隐藏。

lin-cms-vue提供两种方式的权限控制,一种是基于路由配置,后面我们使用到的时候再介绍;另一种就是具体到某个页面元素,在元素标签上使用v-auth指令。后面我们将根据我们之前在编写后端接口权限时的定义,在路由配置中或者页面元素中配置相关的权限控制参数来达到隐藏菜单和按钮。

添加完按钮后,让我们再一次回到浏览器中,刷新一下:

可以看到这里多出了一列,同时每一行都有两个按钮,整个页面现在有模有样了,但是还少了点东西,之前我们在编写查询所有轮播图接口的时候特别让这个接口同时也返回每个轮播图下所展示的轮播图元素,那么具体到页面展示上要怎么体现呢?答案是通过el-table组件提供的展开行特性,要开启这个特性同样很简单,同样是el-table标签内增加一段代码:

<!-- src/views/operation/banner/List.vue -->
<template>
      ...............................
      ...............................
<!-- <el-table v-bind:data="bannerList"> -->
<!-- v-bind:data可以简写成: -->
     <el-table :data="bannerList">
          <el-table-column type="expand">
              <template slot-scope="scope">
                  <div class="expand-container">
                      <div v-for="(img,index) in scope.row.items" :key="index">
                          <img class="img" :src="img.img.url">
                      </div>
                  </div>
              </template>
          </el-table-column>
          <!-- label定义列头显示的文本,prop定义要渲染data数组中元素的哪个字段,width定义列宽 -->
          <el-table-column label="id" prop="id" width="120"></el-table-column>
          .........................................................
          .........................................................
    </el-table>
      ...............................
      ...............................        
</template>

....................................
....................................

<style lang="scss" scoped>
    .button-container{
        margin-top: 30px;
        padding-left: 30px;
    }

    .table-container{
        margin-top: 30px;
        padding-left: 30px;
        padding-right: 30px;

        .expand-container {
            display: flex;
            justify-content: flex-start;
            align-items: center;

            .img {
                margin: 10px;
                width: 200px;
            }
        }
    }
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

这里我们在原来id列增加了新的一个列,不同的是在el-table-column标签中声明了一个type="expand",增了这个属性声明之后,那么在表格的每行的最前面就会多出一行可以展开收缩的按钮,在这个el-table-column里面我们同样嵌套了一个template,标签的内容就是使用v-for(opens new window) 循环渲染(一个轮播图下面可能会1个或者多个轮播图元素)当前行的轮播图下所拥有的轮播图元素。定义完之后,让我们回到浏览器中,刷新一下:

这里可以看到在id列前面多了个蓝色的小箭头,点击后当前行下面会展开出一个新的区域,里面显示的就是当前行的轮播图所拥有的轮播图元素,通过这个小功能可以实现轮播图元素的快速预览。

你可能会发现展开后图片都是显示不出来的,提示图片地址404,这是因为前面我们在测试新增轮播图元素的时候插入的是测试数据不一定是真实存在的。这里可以暂时不必理会,后面我们实现了新增、编辑等功能后就可以修正这些测试数据。

到这里,我们的轮播图列表页面就算是完成得差不多了,当然本章节我们只是完成了数据展示的内容,页面上的新增编辑删除按钮目前我们点击是没有什么反应的,但不要紧,这些内容都将在后续的章节中逐一实现,本小节只是一个热身,实现也很简单,但是后面就会涉及到一些组件间的路由呀,数据传递的知识,那些才会相对复杂些。做好准备!接下来就让我们愉快的进入下一章的学习吧!

# 删除轮播图

本小节我们来实现一下轮播图列表页面中删除表格中的某一条轮播图记录的功能,在前面小节中,我们给删除按钮绑定了一个点击事件的回调方法:

<!-- src/views/operation/banner/List.vue -->
<template>
  <div class="lin-container">
    ...............................
    ...............................
    <div class="table-container">
      <el-table :data="bannerList">
            .........................................................
            .........................................................
            <el-table-column label="操作" fixed="right" width="170">
                <template slot-scope="scope">
                  <el-button plain size="mini" type="primary" @click="handleEdit(scope.row)">编辑</el-button>
                  <el-button plain size="mini" type="danger" @click="handleDel(scope.row.id)" v-auth="'删除轮播图'">删除</el-button>
                </template>
            </el-table-column>
      </el-table>
    </div>
  </div>    
</template>
....................................
....................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

当点击删除按钮的时候,就会触发handleDel()方法,同时会给这个方法传递当前行数据中的id字段的值(scope.row.id),目前我们还没有定义这个方法,接下来就需要来定义一下这个方法:

<!-- src/views/operation/banner/List.vue -->
<template>...</template>

<script>
// 可以简写成 
//import banner from '@/models/banner'
import banner from '../../../models/banner' 

export default {
  name: 'List',
  // 组件的数据对象
  data() {...},
  // 生命周期钩子,在该组件被创建后触发,执行该函数内的逻辑
  created() {...},
  // 组件的选项之一,用于定义该组件所拥有的方法
  methods: {
    // 获取所有轮播图数据
    async getBanners() {
      // 调用banner模型类下的方法,并将结果赋值给组件的数据对象实现数据绑定并响应渲染到页面上
      this.bannerList = await banner.getBanners()
    },
    // 删除按钮的点击事件
    async handleDel(id) {
      console.log(`点击了删除id为${id}的轮播图`)
    },
  },
}
</script>
............................................
............................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

我们在methods选项中新增了一个handleDel(id)方法,为了验证一下这个点击事件真的能进到这个方法并获取到轮播图id,我们在方法体里面打印一点调试信息,然后回到浏览器中,刷新一下页面,按下F12打开控制台,点击一下某条轮播图记录的删除按钮:

这里可以看到随着我们的不停的点击删除按钮,控制台也相应打印出了一些信息,说明我们的按钮点击事件回调的方法是可以正常运行的。

这是一个很常用而且有效的调试手段,对于排查各种“为什么点击了没反应”问题很有帮助。

回调方法没问题,那么就要开始正儿八经写代码了,在这个回调方法中,我们同样需要去调用我们前面编写好的后端接口实现删除这个轮播图记录。打开我们的banner模型,新增一个delBannerByIds()方法:

// src/models/banner.js

import {
  get,
  _delete, // 引入封装好的delete方法,保留字冲突,所以前面加了个_
} from '@/lin/plugins/axios'

class Banner {

  // 是否自行处理接口异常
  handleError = true


  async getBanners() {
    const res = await get('v1/banner')
    return res
  }

  async delBannerByIds(ids) {
    // { ids } 等价于 { ids : ids },对象的key和value命名相同时的一种简写
    const res = await _delete('v1/banner', { ids }, { handleError: this.handleError })
    return res
  }

}

export default new Banner()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

_delete方法的第三个参数接受一个params对象,当传入handleError配置项,且值为true时,如果这个请求后端发生了异常,lin-cms-vue会将这个异常信息抛出,由用户自行try/catch来捕获并处理异常

这里我们给Banner模型新增了一个模型方法delBannerByIds(),方法接收一个ids参数,是一个数组,包含了待删除的id,接着调用lin-cms封装好的_delete()方法实现发起一个DELETE类型的HTTP请求。模型方法定义好之后,我们还需要对这个_delete()方法的源码做一些小改动,打开src\lin\plugins\axios.js文件,在文件的最下方,是_delete()方法的源码,按如下内容修改:

// src\lin\plugins\axios.js

// 源码
// export function _delete(url, params = {}) {
//   return _axios({
//     method: 'delete',
//     url,
//     params,
//   })
// }

// 增加一个data参数
export function _delete(url, data = {}, params = {}) {
  return _axios({
    method: 'delete',
    url,
    params,
    data,
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

修改完毕之后,让我们回归正题,到页面中来调用一下刚刚定义的模型方法:

<!-- src/views/operation/banner/List.vue -->
<template>...</template>

<script>
// 可以简写成 
//import banner from '@/models/banner'
import banner from '../../../models/banner' 

export default {
  name: 'List',
  // 组件的数据对象
  data() {...},
  // 生命周期钩子,在该组件被创建后触发,执行该函数内的逻辑
  created() {...},
  // 组件的选项之一,用于定义该组件所拥有的方法
  methods: {
    // 获取所有轮播图数据
    async getBanners() {
      // 调用banner模型类下的方法,并将结果赋值给组件的数据对象实现数据绑定并响应渲染到页面上
      this.bannerList = await banner.getBanners()
    },
    // 删除按钮的点击事件
    async handleDel(id) {
      // delBannerByIds接收一个数组参数,这里需要用[]来包裹一下
      await banner.delBannerByIds([id])
    },
  },
}
</script>
............................................
............................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

然后我们到浏览器中来测试一下,刷新一下页面,然后点击删除原来表格中id为10的轮播图:

点击删除按钮之后,刷新一下页面:

这里我们发现表格中id为10的轮播图记录已经消失了,这说明我们已经删除成功了,但是这里整个交互体验并不友好,第一是点击删除后没有一个提示,如果我们只是不小心点击到了删除按钮,那么就没救了;第二,我们点击删除之后还需要手动刷新页面才能知道操作后的结果,这简直弱爆了。当然这里两个问题也很好解决,首先是第一个问题的解决方案,我们在每次点击删除按钮之后,给一个提示框,让用户再次确认,根据操作结果来决定是否发起一个删除轮播图的请求,这里我们需要引入element ui 的Dialog组件(opens new window) ,同时调整handleDel()的实现逻辑:

<!-- src/views/operation/banner/List.vue -->
<template>
  <div class="lin-container">
    ...............................
    ...............................
    <div class="table-container">
      <el-table :data="bannerList">
            .........................................................
            .........................................................
            <el-table-column label="操作" fixed="right" width="170">
                <template slot-scope="scope">
                  <el-button plain size="mini" type="primary" @click="handleEdit(scope.row)">编辑</el-button>
                  <el-button plain size="mini" type="danger" @click="handleDel(scope.row.id)" v-auth="'删除轮播图'">删除</el-button>
                </template>
            </el-table-column>
      </el-table>
    </div>
    <el-dialog
        title="提示"
        :visible.sync="showDialog"
        width="30%"
        center>
        <span>确定删除id为{{id}}的轮播图?</span>
        <span slot="footer" class="dialog-footer">
          <el-button @click="showDialog = false">取 消</el-button>
          <el-button type="primary" @click="deleteBanner">确 定</el-button>
        </span>
     </el-dialog>
  </div>        
</template>

<script>
// 可以简写成 
//import banner from '@/models/banner'
import banner from '../../../models/banner' 

export default {
  name: 'List',
  // 组件的数据对象
  data() {
    return {
      bannerList: [],
      // 控制对话框显示/隐藏,默认不显示
      showDialog: false,
      // 轮播图id
      id: null,
    }
  },
  // 生命周期钩子,在该组件被创建后触发,执行该函数内的逻辑
  created() {...},
  // 组件的选项之一,用于定义该组件所拥有的方法
  methods: {
    // 获取所有轮播图数据
    async getBanners() {
      // 调用banner模型类下的方法,并将结果赋值给组件的数据对象实现数据绑定并响应渲染到页面上
      this.bannerList = await banner.getBanners()
    },

    // 删除按钮的点击事件
    handleDel(id) {      
      // 数据绑定,用于显示对话框内容
      this.id = id
      // 数据绑定,显示对话框
      this.showDialog = true
    },

    // 执行删除轮播图请求
    async deleteBanner() {
      // 关闭对话框
      this.showDialog = false
      await banner.delBannerByIds([this.id])
    },
  },
}
</script>

....................................
....................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

这里我们在页面中引入了element ui 的Dialog组件,原来我们是在删除按钮的点击事件中调用Banner模型的方法实现删除轮播图,现在不是了,我们把真正模型方法的调用,延迟到了对话框中的“确定”按钮的点击事件回调方法deleteBanner()中,原来的删除按钮点击事件只负责做数据绑定工作,控制对话框组件的打开和内容渲染。调整完毕之后,让我们到浏览器中刷新一下页面看看效果:

可以看到当我们点击删除按钮的时候,会弹出一个对话框提示我们是否要删除,点击取消的话,对话框消失,刷新一下页面,表格的数据也不会有变化,点击确认之后,会实现和之前一样的效果,表格的某一条记录会被删除,这样我们的第一个问题就算是解决了。而第二个问题的解决方法,同样是简单的添加一些代码即可实现:

<!-- src/views/operation/banner/List.vue -->
<template>
  <div class="lin-container">
    ...............................
    ...............................
    <div class="table-container">
      <el-table v-loading="loading" :data="bannerList">
            .........................................................
            .........................................................
      </el-table>
    </div>
    <!-- 对话框 -->
    <el-dialog>...</el-dialog>
  </div>        
</template>

<script>
// 可以简写成 
//import banner from '@/models/banner'
import banner from '../../../models/banner' 

export default {
  name: 'List',
  // 组件的数据对象
  data() {
    return {
      bannerList: [],
      // 控制对话框显示/隐藏,默认不显示
      showDialog: false,
      // 轮播图id
      id: null,
      // 显示加载状态
      loading: false,
    }
  },
  // 生命周期钩子,在该组件被创建后触发,执行该函数内的逻辑
  created() {...},
  // 组件的选项之一,用于定义该组件所拥有的方法
  methods: {
    // 获取所有轮播图数据
    async getBanners() {
      // 调用banner模型类下的方法,并将结果赋值给组件的数据对象实现数据绑定并响应渲染到页面上
      this.bannerList = await banner.getBanners()
    },

    // 删除按钮的点击事件
    handleDel(id) {      
      // 数据绑定,用于显示对话框内容
      this.id = id
      // 数据绑定,显示对话框
      this.showDialog = true
    },

    // 执行删除轮播图请求
    async deleteBanner() {
      // 关闭对话框
      this.showDialog = false
      // 显示加载状态
      this.loading = true
      // 调用模型方法删除轮播图
      await banner.delBannerByIds([this.id])
      // 再次调用获取所有轮播图的方法
      this.getBanners()
      // 关闭加载状态
      this.loading = false
    },
  },
}
</script>

....................................
....................................

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

这里我们给el-table标签添加了一个element ui封装的自定义指令v-loading(opens new window) ,当这个自定义指令的值为true的时候,那么el-table标签的覆盖范围就会呈现一个加载中的状态,这里我们定义了一个loading对象来动态控制加载状态的显示和隐藏,默认是不显示,控制的地方就是在deleteBanner()方法中,每当确认删除轮播图的时候,对话框关闭之后,表格开始显示加载状态,接着调用模型方法,然后再次调用我们前一节编写的获取所有轮播图的组件方法,这一步就是实现了重新渲染表格数据,最后关闭加载状态。通过这个改动,我们从点击了“删除”按钮开始,整个过程就显得有反馈和人性化,是不是感觉流畅和舒服多了?

记得七月老师曾经说过,前端项目有时候就是在写交互体验。个人感觉也是如此,如果一个前端项目不考虑太多交互体验的问题,那确实没什么难度,当真要充分考虑用户体验的时候,第三方的组件库可以有效提高我们的开发效率和降低开发难度。当然,个别追求极致的可能还需要对组件库进行二次开发。

最后,我们还需要对一些极端情况做处理。当我们确定删除一个轮播图的时候,表格会进入加载状态,但如果banner.delBannerByIds([this.id])这个模型方法执行发生了异常(可能是模型方法内部问题或者后端接口问题),那么表格的加载状态就不会取消,因为这时候loading的值还是true,你就不得不强制刷新浏览器页面来消除这个加载状态,这同样是一个糟糕的体验。所以这里我们需要用一个try/catch包裹一下捕获这些异常:

// 执行删除轮播图请求
    async deleteBanner() {
      // 关闭对话框
      this.showDialog = false
      // 显示加载状态
      this.loading = true
      try {
        // 调用模型方法删除轮播图
        const res = await banner.delBannerByIds([this.id])
        // 再次调用获取所有轮播图的方法
        this.getBanners()
        // 关闭加载状态
        this.loading = false
        // 消息提示
        this.$message({
          message: res.msg,
          type: 'success',
        })
      }catch(e) {
        this.loading = false
        this.$message({
          message: e.data.msg,
          type: 'error',
        })
      }
    },
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

这里我们在删除成功后利用element ui的消息提示(opens new window) 给用户一个删除成功的提示,进一步提升用户体验。而当发生程序异常时,我们同样执行关闭加载状态的处理,同时给出一个错误类型的消息提示,提示内容是我们前面在实现后端接口的时候定义的异常格式中的msg字段内容。

# 新增轮播图

通过前面查询和删除轮播图小节热身,相信读者已经对使用Vue进行一些基本开发有所了解了。因为是热身章节的缘故,我们还没接触到太多关于Vue特性的运用,比如我们前面吹了很久组件,那么从本小节开始,我们就来实践一下如何利用组件的封装、复用等特性实现我们的项目需求。

要操作新增一个轮播图,肯定是在列表页面点击某个按钮,然后弹出个对话框或者切换到一个新的页面进行操作,新增一个轮播图要编辑的内容太多了(标题、概述、轮播图内容的添加),一个对话框肯定很难装得下,所以这里我们选择单独一个页面来实现我们的新增轮播图页面。在src/views/operation/banner/目录下,我们新增一个Add.vue并添加一些骨架内容:

<!-- src/views/operation/banner/Add.vue -->
<template>
    <div class="container">
        <div class="header">
            <span>新增轮播图</span>
            <span class="back" @click="handleBack">
                <i class="iconfont icon-fanhui"/> 返回
            </span>
        </div>
        <el-divider/>
        <div class="form-container">
            <el-row>
                <el-col :lg="16" :md="20" :sm="24" :xs="24">
                    这里是一个表单
                </el-col>
            </el-row>
        </div>
    </div>
</template>

<script>
export default {
  name: 'Add',
  methods: {
    // 返回按钮点击事件
    handleBack() {
      this.$emit('back')
    },
  },
}
</script>

<style lang="scss" scoped>
    .el-divider--horizontal {
        margin: 0
    }

    .container {

        .header {
            height: 59px;
            line-height: 59px;
            color: $parent-title-color;
            font-size: 16px;
            font-weight: 500;
            text-indent: 40px;

            .back {
                float: right;
                margin-right: 40px;
                cursor: pointer;
            }
        }

        .form-container {
            margin-top: 40px;
        }
    }
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

按照习惯,我们先写些测试内容,让页面能按我们的预期显示出来先,回到我们的src/views/operation/banner/List.vue中,我们要实现点击了新增按钮时跳转页面,那么自然就需要给按钮添加点击事件:

<!-- src/views/operation/banner/List.vue -->
<template>
    <div class="lin-container">
        <div class="lin-title">轮播图列表</div>
        <div class="button-container">
            <!-- 添加新增按钮的点击事件 -->
            <el-button type="primary" @click="handleAdd">新增</el-button>
        </div>
        <div class="table-container">...</div>
        <el-dialog>...</el-dialog>
    </div>
</template>

<script>
import banner from '../../../models/banner'

export default {
  name: 'List',
  data() {...},
  created() {...},
  methods: {
    /**获取所有轮播图*/
    async getBanners() {...},

    /**
     * 新增按钮点击事件
     */
    handleAdd() {
      
    },

    /**删除按钮的点击事件*/
    handleDel(id) {...},

    /**执行删除轮播图请求*/
    async deleteBanner() {...},
}
</script>

<style lang="scss" scoped>
/* 省略一堆内容 */
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

这里我们给列表页面上的新增按钮增加了一个点击事件的监听,当点击之后会触发handleAdd()方法,方法内的实现我们第一时间会想到的就是做一个路由跳转,跳转到我们刚刚初步定义好的Add.vue中,这是一种方式。但是这里我们想换一种实现方式。我们不打算选择让Add.vue成为一个独立的页面(像List.vue一样在路由配置文件中注册一个路由),而是在当点击了新增按钮时,List.vue这个页面中直接显示Add.vue的内容,同时隐藏原来List.vue中的内容,相当于List.vue中其实还嵌套了一个Add.vue,List.vue和Add.vue是父子组件关系了。这样做给用户的视觉感受就是切换了页面,但其实并没有,只是切换了显示的组件内容,为啥子要这样做呢?原因是大家可以观察在lin-cms-vue的页面标签栏:

如果我们每个页面都作为页面注册到路由配置中,当我们点击不同功能模块下的页面,标签栏这里都会增加一个标签,这样的结果就是会导致这里堆积一大堆历史标签,当你功能模块比较多的时候,只能通过左侧导航去重新点出来。所以这里我们尽可能的让每个功能模块的组件少注册成单独的页面,而是通过在页面内切换组件来实现页面切换的效果,切换组件的话是不会在标签栏处新增一个标签的。

这里仅作为一个实现方式的参考,如果你觉得新增就是要单独注册成一个页面也是可以的。比如说你觉得这个新增轮播图的页面是一个高频的页面,你希望每次直接就在左侧导航栏就有个菜单可以点击,而且就想有个新增轮播图页面的标签页在标签栏。

实现方式确定了,那么我们来实现一下具体切换的逻辑,打开我们前面编写好的List.vue

<!-- src/views/operation/banner/List.vue -->
<template>
    <div class="lin-container" v-if="!switchComponent">
        <!-- 省略一堆内容 -->
    </div>
    <component v-else :is="targetComponent" @back="handleBack"/>
</template>

<script>
import banner from '../../../models/banner'
// 引入组件
import Add from './Add'

export default {
  name: 'List',
  // 注册组件
  components: { Add },
  data() {
    return {
      bannerList: [],
      // 控制对话框显示/隐藏,默认不显示
      showDialog: false,
      // 轮播图id
      id: null,
      // 显示加载状态
      loading: false,
      // 是否切换组件
      switchComponent: false,
      // 切换的目标组件
      targetComponent: '',
    }
  },
  created() {...},
  methods: {
    /**获取所有轮播图*/
    async getBanners() {...},

    /**
     * 新增按钮点击事件
     */
    handleAdd() {
      this.switchComponent = true
      this.targetComponent = 'Add'
    },

    /**删除按钮的点击事件*/
    handleDel(id) {...},

    /**执行删除轮播图请求*/
    async deleteBanner() {...},
}
</script>

<style lang="scss" scoped>
/* 省略一堆内容 */
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

首先我们在最外层的<div>标签上加了一个v-if的指令,通过判断switchComponent这个数据对象的布尔值来决定是否显示这一块的元素,与之对应的是下面新增加带有v-else指令的<component>标签,如果div这里的逻辑是不显示,那么就会显示component标签的内容。

component标签是Vue框架提供的动态组件(opens new window) 功能,通过给标签内的is属性动态绑定一个组件名实现切换不同的组件,这里的targetComponent变量就代表了某个组件名

也就是说这里我们需要在handleAdd()回调方法里去控制switchComponenttargetComponent,如代码所示:

    /**
     * 新增按钮点击事件
     */
    handleAdd() {
      this.switchComponent = true
      this.targetComponent = 'Add'
    },
1
2
3
4
5
6
7

当点击了新增按钮,我们改变switchComponent的值为truetargetComponent的值为我们的新增轮播图页面的组件,注意这里需要在当前List.vue中引入和注册这个Add.vue组件,不然无法在当前组件中使用引入的组件:

<script>
import banner from '../../../models/banner'
// 引入组件
import Add from './Add'

export default {
  name: 'List',
  // 注册组件
  components: { Add },
  data() {...},
  // 省略后面的内容
</script>  
1
2
3
4
5
6
7
8
9
10
11
12

一个组件中要使用另一个组件都需要遵循引入、注册这两个步骤

这样子当我们点击了新增按钮,就会切换到我们的Add.vue组件了,同时原来的列表页面的内容会被隐藏,但是这里我们发现进去后就出不来了,得刷新一下页面才能回到列表页。不要慌!我们在Add.vue里面是定义了返回按钮和相应点击事件的,他会向父组件发送一个事件:

  // src/views/operation/banner/Add.vue

    // 返回按钮点击事件
    handleBack() {
      this.$emit('back')
    },
1
2
3
4
5
6

这里之所以没反应,是因为虽然我们在List.vue中的<component>标签里定义了一个@back="handleBack"的事件监听,但是具体的回调方法实现我们还没写,方法的内容也非常简单:

<!-- src/views/operation/banner/List.vue -->
<script>
import banner from '../../../models/banner'
// 引入组件
import Add from './Add'

export default {
  name: 'List',
  // 注册组件
  components: { Add },
  data() {...},
  created() {...},
  methods: {
    /**获取所有轮播图*/
    async getBanners() {...},

    /**新增按钮点击事件*/
    handleAdd() {...},

    /**删除按钮的点击事件*/
    handleDel(id) {...},

    /**执行删除轮播图请求*/
    async deleteBanner() {...},

    /**
     * 子组件里点击返回的事件
     */
    handleBack() {
      this.switchComponent = false
      this.targetComponent = ''
      this.getBanners()
    },
}
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

handleBack()方法里做的事情很简单,就是把切换状态改回去,然后重新调用了一次组件方法获取所有轮播图。到这里,我们从列表页到新增轮播图页面的跳转就来去自如了,大家可以多点击几次感受一下那种如丝般顺滑的跳转体验。但是体验之余,也别忘了我们的代码还没写完,目前我们的新增轮播图页面里面还没有实际性的内容,接下来我们就要来编写这部分的内容——就是放一个表单。我们直接往组件里塞个<el-table></el-table>,然后获取表单数据,提交表单就完事了,是的,的确如此,不过我们不想这么简单粗暴,我们要做的是把表单的内容和通用逻辑单独封装一个组件,然后在Add.vue中调用这个组件,读者这里可能又懵逼了,这又是为什么?目的有两个:

  1. 简化Add.vue组件的代码体积
  2. 复用代码

第1点读者们应该很容易可以理解,但是第2点从何谈起?别忘了我们后面还有编辑轮播图这个功能模块还没实现呢!新增和编辑使用的表单其实是一样的,差别是在于新增是没有初始表单数据的,而编辑时有。所以,这里我们把表单部分的内容和一些通用逻辑(表单校验)提取成一个组件,分别在新增和编辑时去引入,这样我们就不用重复写两次同样的代码。看到这里读者可能觉得这么做好处是有,但是也不是特别大,没关系,且跟着内容学习下去,后面我们把代码都写完了,读者们就能明白节省代码只是一个点,还有另外一个点。话不多说,我们动手写代码,在src/views/operation/banner目录下新建一个components目录,然后右键这个目录,新建一个叫Form.Vue文件:

一般根目录下的components目录里的组件视为整个项目公共的组件,即任何其他页面或者组件都可能会去引入和调用这里面的组件;在views下具体某个页面目录下的components目录指专供于这个页面内使用的公共组件,比如这里Form组件就是给banner页面模块内共用的。

然后我们往Form.vue中,塞进去一大堆代码:

<template>
    <el-form ref="form" :rules="rules" :model="temp" status-icon label-width="100px" @submit.native.prevent>
        <el-form-item label="名称" prop="name">
            <el-input size="medium" v-model="temp.name" placeholder="轮播图名称"/>
        </el-form-item>
        <el-form-item label="简介" prop="description">
            <el-input size="medium" type="textarea" :rows="4" placeholder="可选,轮播图简介" v-model="temp.description"/>
        </el-form-item>
        <!--轮播内容-->
        <el-form-item prop="items">
            <div v-if="temp.items.length" class="banner-item">
                <div v-for="(item,index) in temp.items" :key="index">
                    <div class="banner-item-title">
                        <div class="banner-item-title-text">轮播图{{ index+1 }}</div>
                        <l-icon name="minus-circle" color="#F4516C" @click="handleMinusItem(index)"/>
                    </div>
                    <el-form-item label="关键字" :prop="'items.'+index+'.key_word'"
                                  :rules="bannerItemRules.key_word">
                        <el-col :span="5">
                            <el-input size="medium" v-model="temp.items[index].key_word" placeholder="执行关键字"/>
                        </el-col>
                    </el-form-item>
                    <el-form-item label="跳转类型" :prop="'items.'+index+'.type'" :rules="bannerItemRules.type">
                        <el-select v-model="temp.items[index].type" placeholder="请选择">
                            <el-option
                                v-for="item in options"
                                :key="item.value"
                                :label="item.label"
                                :value="item.value">
                            </el-option>
                        </el-select>
                    </el-form-item>
                    <el-form-item label="轮播内容" :prop="'items.'+index+'.img_id'" :rules="bannerItemRules.img_id">
                      <div  @click="handleUploadImg(index)">
                          <upload-imgs ref="uploadEle" :max-num="1" :value="bannerItemImg[index]"
                                       :remote-fuc="uploadImage"/>
                      </div>
                    </el-form-item>
                    <div v-if="index ===temp.items.length-1">
                        <el-link @click="handlePlusItem">添加<i class="el-icon-circle-plus-outline el-icon--right"/>
                        </el-link>
                    </div>
                </div>
            </div>
            <div v-else>
                <el-link @click="handlePlusItem">添加<i class="el-icon-circle-plus-outline el-icon--right"/></el-link>
            </div>
        </el-form-item>
        <!--按钮-->
        <el-form-item>
            <el-button @click="resetForm">重 置</el-button>
            <el-button type="primary" @click="handleSubmit">保 存</el-button>
        </el-form-item>
    </el-form>
</template>

<script>
import UploadImgs from '@/components/base/upload-imgs'
import { customImageUpload } from '@/lin/utils/file'

/** 生成随机字符串 */
function createId() {
  return Math.random()
  .toString(36)
  .substring(2)
}

export default {
  name: 'Form',
  components: { UploadImgs },
  props: {
    data: {
      type: Object,
      default: null,
    },
  },
  data() {
    return {
      temp: {
        id: null,
        name: null,
        description: null,
        items: [],
      },
      options: [
        {
          value: 0,
          label: '无导向',
        },
        {
          value: 1,
          label: '导向商品',
        },
        {
          value: 2,
          label: '导向专题',
        },
      ],
      currentUploadImgIndex: null,
      bannerItemImg: [],
      rules: {
        name: [
          {
            required: true,
            message: '请输入轮播图名称',
            trigger: 'blur',
          },
        ],
        items: [
          {
            required: true,
            message: '轮播图元素不能为空',
            trigger: 'blur',
          },
        ],
      },
      bannerItemRules: {
        key_word: [
          {
            required: true,
            message: '请配置关键字',
            trigger: 'blur',
          },
        ],
        type: [
          {
            required: true,
            message: '请配置跳转类型',
            trigger: 'blur',
          },
        ],
        img_id: [
          {
            required: true,
            message: '请上传轮播图图片',
            trigger: 'blur',
          },
        ],
      },
    }
  },
  created() {
    // 如果有传入data就赋值给temp,没有就保持原来的temp,对应新增和编辑的场景
    this.temp = this.data != null ? JSON.parse(JSON.stringify(this.data)) : this.temp

    // 存在轮播图元素,初始化轮播图元素的图片组件
    if (this.temp.items.length > 0) {
      this.initBannerItemImage()
    }
  },
  methods: {
    /**
     * 初始化图片上传组件
     */
    initBannerItemImage() {
      this.bannerItemImg = []
      for (let i = 0; i < this.temp.items.length; i++) {
        const item = this.temp.items[i]
        const img = [{
          id: createId(),
          imgId: item.img.id,
          display: item.img.url,
        }]
        this.bannerItemImg.push(img)
      }
    },
    /**
     * 添加轮播图元素点击事件
     */
    handlePlusItem() {
      const item = {
        id: '',
        banner_id: this.temp.id,
        key_word: '',
        img_id: '',
        img: {
          id: '',
          url: '',
        },
      }
      this.temp.items.push(item)
      this.bannerItemImg.push([])
    },
    /**
     * 删除轮播图图元素点击事件
     */
    handleMinusItem(index) {
      this.temp.items.splice(index, 1)
      this.bannerItemImg.splice(index, 1)
    },
    /**
     * 提交表单
     */
    async handleSubmit() {
      // 遍历banner下的items数组,把图片上传组件中的图片id赋值给每个item
      // 这一步不做的话,图片元素的表单检验不会通过
      for (let i = 0; i < this.temp.items.length; i++) {
        // 取出图片上传组件中的数据,因为索引与items是一致的,可以一一对应每个item
        // eslint-disable-next-line no-await-in-loop
        const img = await this.$refs.uploadEle[i].getValue()
        // 图片上传组件中有图片
        if (img.length > 0) {
          // 把对应的图片id赋值给item
          this.temp.items[i].img_id = img[0].imgId
        } else {
          // 图片上传组件中没有图片,新增的时候或者清空了原来已有的图片
          this.temp.items[i].img_id = ''
        }
      }
      // 获取表单验证的结果,都通过就触发一个提交表单的事件
      this.$refs.form.validate((valid) => {
        if (valid) {
          this.$emit('submit', this.temp)
        }
      })
    },
    /**
     * 重置表单
     */
    resetForm() {
      if (this.data != null) {
        this.temp = JSON.parse(JSON.stringify(this.data))
      } else {
        this.temp = {
          id: null,
          name: null,
          description: null,
          items: [],
        }
      }

      if (this.temp.items.length > 0) {
        this.initBannerItemImage()
      }
    },
    /**
     * 自定义上传图片方法
     */
    async uploadImage(file) {
      const res = await customImageUpload(file)
      for (let i = 0; i < res.length; i++) {
        const img = [{
          id: createId(),
          imgId: res[i].id,
          display: res[i].url,
        }]
        this.bannerItemImg[this.currentUploadImgIndex] = img
        return Promise.resolve({
          id: res[i].id,
          url: res[i].url,
        })
      }
    },
    /**
     * 记录每次点击图片上传组件时当前轮播图元素的索引
     * 这个索引用于在每次成功上传图片后给bannerItemImg赋值用
     * @param index
     */
    handleUploadImg(index){
      console.log(index)
      this.currentUploadImgIndex = index
    }
  },
}
</script>

<style lang="scss" scoped>
    .banner-item-title {
        display: flex;
        align-items: center;

        .banner-item-title-text {
            margin-right: 10px;
        }
    }
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277

看到这一堆东西是不是瞬间懵逼了,这里上来就一堆代码完全不符合我们以往的专栏风格对吧?是的,但是这里这么做的目的就是想告诉读者,这里的代码,如果我们不封装独立成一个组件来实现复用,那么Form.vue里的内容你在新增和编辑的两个页面组件中就要重复写两遍,这里的代码量大家自行感受。

当然这里还有另外一种实现方式,就是我们不通过两个.vue文件来对应编辑和删除,把代码都冗余到一个.vue文件中。在列表页点击进来的时候给这个组件传递一个标识,标识本次是新增还是编辑。这样同样可以实现代码复用,但是单个文件的代码体积会显得臃肿,而且还是难以体现拆分.vue文件的一个核心优势,这里同样暂不透露。

那么大概感受了下代码量之后,我们还是得回到代码本身,我们得知道这里面到底干了啥,内容看着挺多,其实并不复杂,我们自定义的Form组件只是负责显示和编辑表单数据,提供表单验证,保存后向父组件发送一个提交表单的事件并附带表单数据,至于表单数据如何使用,由父组件决定。也就是说,Form表单本身不会发起什么后端请求,它只处理和表单数据本身有关的事情,这就是Form组件的独立性和单一职责性。

Vue的组件概念和面向对象有很多共通之处。

接下来给大家剖析一下Form组件的具体实现,当然一行行给大家讲解肯定不现实,我写着累你看着也累,很容易看着看着就忘记前面说了啥了,只有重点和难点内容会着重分析,一些固定写法或者官方文档有更好的解释的我会选择直接给出链接。

<template>标签部分,只放了一个el-form(opens new window) 标签,这同样是Element UI提供的组件,el-form标签里面配置了多个属性,比较关键的三个属性作用如下:

  • ref:注册一个对象,代表DOM或者组件实例,官方文档(opens new window)
  • rules:传入一个数组,定义表单项的验证规则
  • model:接收一个对象,作为传入表单的数据,对表单所做的修改也会作用到这个对象中,我们提交表单就是拿这个对象作为最终提交的数据。

el-form标签中放置了多个el-form-item,从标签名可以看出就是代表了每一个表单项,你有多少个表单项,就放置多少个el-form-item。label是表单项的标签文本,prop属性的值需要与el-form标签model属性的传入对象里某个字段对应,当需要表单验证时,这个属性是必传的。 el-form-item标签中放置的内容就是一些表单元素了,比如输入框(opens new window) 选择器(opens new window) ,整个表单组件中比较复杂的部分在于轮播图元素这里的交互逻辑,包括初始化、新增、删除、编辑、校验这几个方面。因为轮播图元素涉及到图片内容,所以处理起来会比较麻烦些,首先我们引入了LinCMS内置的图片上传组件(opens new window) 用于展示和上传轮播图元素的图片内容:

<div v-if="temp.items.length" class="banner-item">
    <div v-for="(item,index) in temp.items" :key="index">

        <!-- 省略部分el-form-item内容 -->
         <el-form-item label="轮播内容" :prop="'items.'+index+'.img_id'" :rules="bannerItemRules.img_id">
            <div  @click="handleUploadImg(index)">
                <upload-imgs ref="uploadEle" :max-num="1" :value="bannerItemImg[index]"
                              :remote-fuc="uploadImage"/>
            </div>
          </el-form-item>
        <!-- 省略部分el-form-item内容 -->
    </div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13

我们注册一个uploadEle对象来引用这个图片上传组件,目的是方便后面我们在js需要通过调用这个对象来执行一些方法获取图片上传的信息。max-num指定了每次允许上传的数量,我们一个轮播图元素只会对应一张图片,所以赋值1;value是图片上传组件里的数据,我们会封装生成一个bannerItemImg数组来提供相应的数据;remote-fuc用于指定图片上传所调用的函数,默认的实现会走后端封装好的图片上传接口,但是这里因为我们需要根据原来零食商贩项目的数据库设计来生成记录,所以我们要自定义一个上传方法。图片上传组件还有很多丰富和便捷的功能,读者可以自行阅读官方文档了解。

<template>标签中绝大数的内容都是固定用法,通过查看对应的文档即可明白,这里就不再赘述。唯一特别的是轮播图元素部分的表单校验,以往我们在使用Element UI表单组件的表单校验,只需要在<el-form-item>标签中声明prop属性,属性的值是对应传入<el-form>中的model属性的对象里的某个字段即可轻松实现根据rules里定义的规则来做表单校验,但是你会发现按照传统的方式去定义和操作的话,表单校验会出现各种问题,原因在于轮播图元素这块的内容是循环出来的,按照常规的方式定义表单校验的功能不知道到底是校验的哪一个元素,幸运的是这个问题在搜索引擎上已经有了解决办法,就是目前专栏里的这种写法,比如前面我们这个轮播图元素里图片元素的校验:

<el-form-item label="轮播内容" :prop="'items.'+index+'.img_id'" :rules="bannerItemRules.img_id" />
1

prop属性的赋值需要通过这种特殊的格式来赋值,同时再单独定义一个bannerItemRules验证规则数组。

这里读者不用太纠结为什么要这么写,因为我也不知道,只找到了这个实现方法。

接下来是<script>标签里的内容,为了避免必要的篇幅,我在前面示例代码中已经附上了详细的注释,读者可以自行参考注释来理解。这里我们唯一要做的就是实现一个自定义图片上传方法,我们一开始就引入了:

import { customImageUpload } from '@/lin/utils/file'
1

这里方法默认是没有的,得我们自己写,在src/lin/utils下新增一个file.js文件并添加如下代码:

import {
  post,
} from '@/lin/plugins/axios'

export async function customImageUpload(file) {
  const res = await post('cms/file/image', { file })
  return res
}

1
2
3
4
5
6
7
8
9

这里面的实现就是发起一个POST请求上传一个图片,这个接口我们在前面实现后端接口部分的时候是没有的,所以这里我们需要打开我们的后端项目,补充实现一下这个接口,使用PHPStrom打开我们久违的后端工程目录,在根目录下的application\api\controller\cms\File.php控制器类中新增一个postCustomImage控制器方法:

<?php
/*
* Created by DevilKing
* Date: 2019- 06-08
*Time: 16:26
*/

namespace app\api\controller\cms;

use app\lib\file\ZergImageUploader;
use think\facade\Request;
use app\lib\file\LocalUploader;
use app\lib\exception\file\FileException;

/**
 * Class File
 * @package app\api\controller\cms
 */
class File
{
    /**LinCMS内置图片上传方法*/
    public function postFile(){...}

    /**
     * 自定义图片上传方法
     */
    public function postCustomImage()
    {
        try {
            $request = Request::file();
        } catch (\Exception $e) {
            throw new FileException([
                'msg' => '字段中含有非法字符',
            ]);
        }
        $result = (new ZergImageUploader($request))->upload();

        return $result;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

这里我们还需要封装一个图片上传的类,在项目根目录下的lib\file下新增一个ZergImageUploader.php文件,并添加如下代码:

<?php


namespace app\lib\file;


use app\api\model\Image;
use app\lib\exception\file\FileException;
use LinCmsTp\File;
use think\facade\Config;
use think\facade\Env;

class ZergImageUploader extends File
{

    public function upload()
    {
        $ret = [];
        $this->storeDir = 'images';
        $host = Config::get('setting.img_prefix');
        foreach ($this->files as $key => $file) {

            $info = $file->move(Env::get('root_path') . '/' . 'public' . '/' . $this->storeDir);
            if ($info) {
                $path = str_replace('\\', '/', $info->getSaveName());
            } else {
                throw new FileException([
                    'msg' => $this->getError,
                    'error_code' => 60001
                ]);
            }

            $image = Image::create([
                'url' => '/' . $path,
                'from' => 1,
            ]);
            array_push($ret, [
                'id' => $image->id,
                'url' => $host . '/' . $path
            ]);
        }
        return $ret;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

这里的实现没有什么技术难度和特别的知识点,就是把图片存到服务器,并按照零食商贩本身数据库表设计的规则把对应的信息填充到image表中并返回一些记录的相关信息,比如图片的id和url地址。方法实现完之后,我们还需要给接口定义一条路由,打开route.php,在cms路由分组下新增一条路由规则:

Route::group('', function () {
    Route::group('cms', function () {
        // 账户相关接口分组
        Route::group('user', function () {...});
        // 管理类接口
        Route::group('admin', function () {...});
        // 日志类接口
        Route::group('log',function (){...});
        //上传文件类接口
        Route::post('file', 'api/cms.File/postFile');
        // 自定义文件上传接口
        Route::post('file/image', 'api/cms.File/postCustomImage');
    });
    // 业务接口相关的路由规则
    Route::group('v1', function () {
      //  省略一堆内容
    });

})->middleware(['Auth', 'ReflexValidate'])->allowCrossDomain();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

经过一番努力,我们封装了一个Form.vue组件,同时给后端新增了一个自定义图片上传的接口来满足组件的内在实现需求,接着我们要做的就是把这个我们封装的Form.vue组件用起来了,我们在Add.vue组件中引入这个组件:

<!-- src/views/operation/banner/Add.vue -->

<template>
    <div class="container">
        <div class="header">
            <span>新增轮播图</span>
            <span class="back" @click="handleBack">
                <i class="iconfont icon-fanhui"/> 返回
            </span>
        </div>
        <el-divider/>
        <div class="form-container">
            <el-row>
                <el-col :lg="16" :md="20" :sm="24" :xs="24">
                    <BannerForm @submit="handleSubmit"/>
                </el-col>
            </el-row>
        </div>
    </div>
</template>

<script>
import BannerForm from './components/Form'
import banner from '@/models/banner'

export default {
  name: 'Add',
  components: { BannerForm },
  methods: {
    async handleSubmit(formData) {
      // 格式化数据
      // map函数和for函数的对比,根据喜好选择。

      // const items = []
      // for (let i = 0; i < formData.items.length; i++) {
      //   const item = {
      //     img_id: formData.items[i].img_id,
      //     key_word: formData.items[i].key_word,
      //     type: formData.items[i].type,
      //   }
      //   items.push(item)
      // }
      // formData.items = items

      formData.items = formData.items.map(item => ({
        img_id: item.img_id,
        key_word: item.key_word,
        type: item.type,
      }))

      // 调用模型方法新增轮播图 
      try {
        const res = await banner.createBanner(formData)
        // 添加成功,弹出一条消息提示
        this.$message.success(res.msg)
        // 触发一次返回按钮事件,回到列表列表页面
        this.handleBack()
      } catch (e) {
        // 提示异常信息
        this.$message.error(e.data.msg)
      }
    },
    handleBack() {
      this.$emit('back')
    },
  },
}
</script>

<style lang="scss" scoped>
  /* 省略内容 */
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73

这里我们在Add.vue组件中引入了我们前面封装好的Form.vue组件,同时监听组件里的submit自定义事件,这个事件代表了提交表单,当触发了这个自定义事件时就会调用handleSubmit()函数来处理,函数内的实现首先是格式化数据,接着调用banner模型内的createBanner方法,这里的模型方法我们还没实现,我们来实现一下,打开banner模型类文件,添加如下代码:

// src/models/banner.js

import {
  get,
  post,
  _delete, // 引入封装好的delete方法,保留字冲突,所以前面加了个_
} from '@/lin/plugins/axios'

class Banner {

  // 是否自行处理接口异常
  handleError = true


  async getBanners() {...}

  async delBannerByIds(ids) {...}

  async createBanner(data) {
    const res = await post('v1/banner', { ...data }, { handleError: this.handleError })
    return res
  }

}

export default new Banner()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

到这里,我们完成了新增轮播图功能的实现,接下来就是到浏览器中刷新一下页面然后自行测试体验。在体验之余,我们来稍微回顾一下我们的代码,从目前的实现效果来看,我们的Add.vue组件中的代码量是很少的,这个组件只做了两件事,一个是处理表单提交的事件,即调用模型方法去实现调用后端接口插入一条记录;另一个就是返回按钮的点击事件。在新增轮播图这个业务逻辑中,关键的地方是在于表单数据的处理和页面的交互,这部分恰恰又是代码内容很容易出现重复的,所以我们把这部分的内容封装到一个单独的组件中,接下来我们就马上进入下一个小节,我们将复用这个封装好的组件来快速实现编辑轮播图

# 编辑轮播图

通过前面新增轮播图小节的铺垫,我们封装了一个组件用于展示和处理表单数据,借助这个组件,我们可以快速实现编辑轮播图功能。和切换到新增轮播图组件的方式一样,当我们要编辑某一个轮播图元素时同样采取切换组件的方式,相关的逻辑我们在前面小节也是同样做好了铺垫了,只需要添加相应的逻辑,打开我们的List.vue

<!-- src/views/operation/banner/List.vue -->
<template>
    <div class="lin-container" v-if="!switchComponent">
        <!-- 省略一堆内容 -->
    </div>
    <component v-else :is="targetComponent" :banner="row" @back="handleBack"/>
</template>

<script>
import banner from '../../../models/banner'
// 引入组件
import Add from './Add'
import Edit from './Edit'

export default {
  name: 'List',
  // 注册组件
  components: { Add, Edit },
  data() {
    return {
      bannerList: [],
      // 控制对话框显示/隐藏,默认不显示
      showDialog: false,
      // 轮播图id
      id: null,
      // 显示加载状态
      loading: false,
      // 是否切换组件
      switchComponent: false,
      // 切换的目标组件
      targetComponent: '',
      // 点击的行数据
      row: null,
    }
  },
  created() {...},
  methods: {
    /**获取所有轮播图*/
    async getBanners() {...},

    /**
     * 新增按钮点击事件
     */
    handleAdd() {...},

     /**
     *  编辑按钮点击事件
     */
    handleEdit(row) {
      this.switchComponent = true
      this.targetComponent = 'Edit'
      this.row = row
    },

    /**删除按钮的点击事件*/
    handleDel(id) {...},

    /**执行删除轮播图请求*/
    async deleteBanner() {...},
}
</script>

<style lang="scss" scoped>
/* 省略一堆内容 */
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

这里我们只需要在List.vue中编写相应的编辑按钮点击的事件回调方法,方法内的实现和处理新增按钮时的内容大同小异,区别就是切换的目标组件不一样了,还有就是把回调函数接收到的行数据绑定到页面中,同时对<component>标签稍加修改,定义一个banner属性的接收并把行数据传递进去。

父组件给子组件传递数据需要通过属性,即在子组件中定义一个props,父组件在调用子组件时通过给这个props赋值,子组件中就可以接收到数据。相反,子组件要给父组件传递数据,需要通过自定义事件,自定义事件的使用前面已经演示过,记住这两个不同流向的数据传递机制很重要。

然后我们来创建一下Edit.vue这个组件,在src/views/operation/banner下新增一个Edit.vue文件并添加如下内容:

<template>
    <div class="container">
        <div class="header">
            <span>编辑轮播图</span>
            <span class="back" @click="handleBack">
                <i class="iconfont icon-fanhui"/> 返回
            </span>
        </div>
        <el-divider/>
        <div class="form-container">
            <el-row>
                <el-col :lg="16" :md="20" :sm="24" :xs="24">
                    <BannerForm :data="formData" @submit="handleSubmit"/>
                </el-col>
            </el-row>
        </div>
    </div>
</template>

<script>
import banner from '@/models/banner'
import BannerForm from './components/Form'

export default {
  name: 'Edit',
  components: { BannerForm },
  props: {
    banner: Object,
  },
  data() {
    return {
      formData: null,
    }
  },
  created() {
    // 深拷贝
    this.formData = JSON.parse(JSON.stringify(this.banner))
  },
  methods: {
    // 表单组件的提交事件
    async handleSubmit(formData) {
     
    },
    // 返回按钮的点击事件
    handleBack() {
      this.$emit('back')
    },
  },
}
</script>

<style lang="scss" scoped>

    .el-divider--horizontal {
        margin: 0
    }

    .container {

        .header {
            height: 59px;
            line-height: 59px;
            color: $parent-title-color;
            font-size: 16px;
            font-weight: 500;
            text-indent: 40px;

            .back {
                float: right;
                margin-right: 40px;
                cursor: pointer;
            }
        }

        .form-container {
            margin-top: 40px;
        }
    }
</style>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

Edit.vue创建完毕之后,到浏览器中刷新一下页面,点击某一条轮播图记录的编辑按钮你就会发现,我们的页面就出来了,除了点击保存后没啥反应之外,这是因为我们还没对表单的提交事件做相应的处理。这里我们再一次复用了我们上一小节中封装的表单组件,有别与新增轮播图,编辑轮播图的情况下我们需要给这个表单组件的data属性传递一条完整的banner数据,这个是在前面小节封装的时候已经预设好的属性。

这时候组件封装的优势就体现出来了,我们只需要简单的几行代码就快速实现了一个页面的基础功能,接下来我们只需要关注Edit.vue里的handleSubmit()方法实现即可。说到这里的表单提交事件处理函数,要做的事简单来说也还是调用模型方法,但是具体实现过程就稍显复杂了。我们可以观察下我们的编辑轮播图页面,然后设想一下当我们要编辑一个轮播图的时候,我们可能会做什么操作,情况有以下几种:

  • 编辑轮播图的名称或者简介
  • 新增一个轮播图元素
  • 删除一个轮播图元素
  • 编辑轮播图元素

最难受的一点是,以上几种操作情况,很大概率会是同时产生的,这也是真实生产环境中会发生的事情,你不可能要求用户在编辑轮播图的时候,只能做某一个操作或者按一定操作顺序来编辑一个轮播图,所以我们在Edit.vuehandleSubmit()中接收到表单组件提交过来的表单数据时就要来做一个数据解析工作,我们要判断提交过来的表单数据和原始的表单数据差异,分析用户在这一次编辑操作中都涉及到了什么操作,然后分别调用对应的后端接口,在前面后端开发的章节部分,我们已经把对应的几个接口都开发完毕了,就是为了此时此刻。业务需求明确了,我们来实现一下这个handleSubmit()里的具体实现:

<script>
// 引入banner模型
import banner from '@/models/banner'
import BannerForm from './components/Form'

export default {

...................
...................

  methods: {
    // 表单组件的提交事件
    async handleSubmit(formData) {
      try {
        // 轮播图基础信息部分的逻辑处理
        await this.updateBannerInfo(formData)
        // 轮播图元素部分的逻辑处理
        await this.updateBannerItem(formData.items)
        this.$message.success('编辑成功')
        this.handleBack()
      } catch (e) {
        this.$message.error(e)
      }
    },

    // 轮播图信息部分逻辑逻辑
    async updateBannerInfo(formData) {
      // 判断轮播图名称和简介与原数据是否存在差异
      if (formData.name !== this.banner.name || formData.description !== this.banner.description) {
        const { id, name, description } = formData
        try {
          // 调用模型的编辑轮播图信息方法
          await banner.editBanner(id, name, description)
        } catch (e) {
          throw Object.values(e.data.msg).join(';')
        }
      }
    },

    // 轮播图元素部分逻辑处理
    async updateBannerItem(bannerItems) {
      // 待新增的bannerItem信息
      let addBannerItems = []
      // 待修改的bannerItem信息
      let editBannerItems = []
      // 待删除的bannerItem信息
      let delBannerItems = []

      // 如果轮播图元素存在变化,把轮播图元素数组分别传递给三个加工函数处理
      if (JSON.stringify(this.banner.items) !== JSON.stringify(bannerItems)) {
        addBannerItems = this._processAddBannerItemsArray(bannerItems)
        editBannerItems = this._processEditBannerItemsArray(bannerItems)
        delBannerItems = this._processDelBannerItemsArray(bannerItems)
      }
      try {
        // 判断是否需要发起删除bannerItem
        if (delBannerItems.length > 0) {
          const ids = delBannerItems.map(item => item.id)
          await banner.delBannerItems(ids)
        }
        // 判断是否需要发起新增bannerItem
        if (addBannerItems.length > 0) {
          await banner.addBannerItems(addBannerItems)
        }
        // 判断是否需要发起更新bannerItem
        if (editBannerItems.length > 0) {
          await banner.editBannerItems(editBannerItems)
        }
      } catch (e) {
        throw Object.values(e.data.msg).join(';')
      }
    },

    // 待新增的bannerItems信息的私有加工函数
    _processAddBannerItemsArray(bannerItems) {
        // 如果轮播图元素的id是空的,代表是一个新增的轮播图元素,因为没有写入过数据库不会有id
        return bannerItems.filter(item => item.id === '')
    },
    // 待编辑的bannerItems信息的私有加工函数
    _processEditBannerItemsArray(bannerItems) {
        const oriBannerItems = this.banner.items
        return bannerItems.filter(item => {
          // 找到相同id的轮播图元素
          const oriItem = oriBannerItems.find(i => i.id === item.id)
          // 这里要考虑如果原bannerItem被删除了,那么在表单数据里面肯定是找不到的
          // find()函数在找不到条件的结果时会返回一个undefined
          if (typeof (oriItem) !== 'undefined') {
            // 比对两个元素的各项值是否存在差异,只要其中一个有变化,就是属于待修改的
            return item.key_word !== oriItem.key_word || item.type !== oriItem.type || item.img_id !== oriItem.img_id
          }
        })
    },
    // 待删除的bannerItems信息的私有加工函数
    _processDelBannerItemsArray(bannerItems) {
        const oriBannerItems = this.banner.items
        // 如果原bannerItem被删除了,那么在表单数据里面肯定是找不到的
        // find()函数在找不到条件的结果时会返回一个undefined
        return oriBannerItems.filter(oriItem => {
          const res = bannerItems.find(item => item.id === oriItem.id)
          return typeof (res) === 'undefined'
        })
    },
    // 返回按钮的点击事件
    handleBack() {...},
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106

这里我们定义了一个handleSubmit()方法,方法接收的参数就是表单组件提交过来的表单数据。在方法内部的实现里,我们把表单数据分别交给了updateBannerInfo()updateBannerItem()这两个方法去做进一步的逻辑处理。updateBannerInfo()的内部实现比较简单,这里就不多赘述,我们主要谈谈updateBannerItem()。在updateBannerItem()方法中,主要做的事情就是判断轮播图元素是否存在变化,如果存在变化,把数据交给三个对应的“加工函数”去处理并拿到返回结果。这里三个函数内在实现其实就是for和if,只不过这里我们使用了一些JavaScript的内置函数来替代那些for和if,在一开始的实现中,作者并没有选择定义三个小函数来处理,而是在updateBannerItem()中通过嵌套for和if来实现:

注释部分里的最初版本代码会有个逻辑错误,你能试着找出来吗?

这两种实现方式的好坏各人有各人的看法,但是这里我还是说说这种拆分出来的方式的好处。其实好处大家也应该猜到了,就和前面我们去设计Vue的组件结构一样,拆分意味着更灵活和可维护。原来的实现方式可读性还是比较差的,而且当出现问题的时候,这种for+if多层嵌套的结构也会增加你定位问题的成本。拆分出来之后,单个函数的逻辑代码因为职责的单一能更好的被阅读理解。当然这么做缺点也是有的,就是重复的循环多了,但是这里作者选择放弃性能的考虑。这里不是说性能不重要,而是说在这个业务场景下,性能的影响微乎其微,这里选择更好的代码可读性和维护性的方案性价比更高。

在最后通过判断这三个返回结果的数组长度是否为空,我们就能知道用户是否做了对应的操作,然后我们就可以有针对性的发起请求了,在banner模型中实现对应的几个方法,打开banner.js模型类文件:

// src/models/banner.js

import {
  get,
  post,
  _delete,
  patch,
  put,
} from '@/lin/plugins/axios'

class Banner {

  // 是否自行处理接口异常
  handleError = true


  async getBanners() {...}

  async delBannerByIds(ids) {...}

  async createBanner(data) {...}

  /**
   * 编辑轮播图信息
   */
  async editBanner(id, name, description) {
    const res = await patch(`v1/banner/${id}`, {
      name,
      description,
    }, { handleError: this.handleError })
    return res
  }

  /**
   * 新增轮播图元素
   */
  async addBannerItems(items) {
    const res = await post('v1/banner/item', { items }, { handleError: this.handleError })
    return res
  }

  /**
   * 删除轮播图元素
   */
  async delBannerItems(ids) {
    const res = await _delete('v1/banner/item', { ids }, { handleError: this.handleError })
    return res
  }

  /**
   * 编辑轮播图元素
   */
  async editBannerItems(items) {
    const res = await put('v1/banner/item', { items }, { handleError: this.handleError })
    return res
  }
}

export default new Banner()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

这里要注意,lin-cms-vue封装的axios.js中没有提供PATCH类型的HTTP请求,需要我们手动改源码来实现一个(是不是很酷?),打开src/lin/plugins/axios.js,在末尾处增加一个函数:

// src/lin/plugins/axios.js

// ajax 封装插件, 使用 axios
import Vue from 'vue'
import axios from 'axios'
import Config from '@/config'
import ErrorCode from '@/config/error-code'
import store from '@/store'
import { getToken, saveAccessToken } from '@/lin/utils/token'


// 省略了很多中间内容

export function patch(url, data = {}, params = {}) {
  return _axios({
    method: 'patch',
    url,
    params,
    data,
  })
}

export default _axios

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

一顿操作之后,我们的编辑轮播图页面的工作就算实现完毕了,从内容可以看出,本小节我们主要的精力就是花在了处理表单提交过来的数据而已,至于表单本身的一些行为和逻辑我们就是完完全全复用上一小节封装好的表单组件而已,大大节省了我们的开发代码量,同时职责的分离也让我们更好的专注实现具体业务逻辑。

# 章节回顾

在本章节中,从一开始热身熟悉框架到后面的组件封装和设计,我们实现了轮播图的一系列管理操作,目前我们的轮播图管理模块的页面组件结构是这样子的:

前面我们在新增轮播图和编辑轮播图小节中反复提到了,我们封装Form.vue这个组件除了实现简化页面组件代码体积、复用代码以外还有一个目的,之前一直没有说明,这里我们就来揭晓下答案——可维护、可扩展性。这个优点在还没完成编辑轮播图部分的内容时可能感触不会太直观,但是当我们把整个轮播图管理功能都实现完毕之后你就会发现,我们相对比较复杂的新增和编辑功能对应的.vue文件中的逻辑其实很少,而且做的事情也很纯粹,这么做的好处就是当出现问题时可以很容易迅速定位到问题所在,这一点在我实现本章节内容的代码时就深有体会。当一些行为不符合预期时,通过简单的调试就可以定位到问题到底是在表单组件内还是表单组件外,而不用在一堆代码里面找问题。同时因为新增、编辑页面组件和表单组件的分离,后期如果我们业务需求有调整,那我们只需要调整对应新增或者编辑组件里的handleSumbit()方法的实现即可,而且这种改动不会影响到其他功能,因为我们每个组件在开发之初就让它尽可能的独立了。最后再次要强调一点的是,如果我们只是单纯的想实现某个功能,那么实现的方式和手段是多种多样的,差别只在于稳定性、维护性、扩展性。在作者早期接触编程的过程中就遇到过同样的功能N种实现的情况,然后就很懵到底哪种才是对的?或者说更规范?这个问题答案挺复杂,很多时候没有对错之分,只有合不合适,这像极了爱情。最后我的个人经验就是,选一个你看得懂的,你觉得用起来顺手的,后面你自然会知道哪种方式合适。这个建议的理由很简单,就是实践才是检验真理的唯一标准,不要试图在不动手的前提下搞懂一个你原来不了解的东西。

最后更新: 2021-08-12 13:31:59
0/140
评论
0
暂无评论
  • 上一页
  • 首页
  • 1
  • 尾页
  • 下一页
  • 总共1页